Skip to content

security: validate contract inputs and bound registry metadata (WNS-03, WNS-09, WNS-10, WNS-11, WNS-12) - #551

Merged
heifner merged 5 commits into
masterfrom
fix/wns-contract-input-validation
Aug 10, 2026
Merged

security: validate contract inputs and bound registry metadata (WNS-03, WNS-09, WNS-10, WNS-11, WNS-12)#551
heifner merged 5 commits into
masterfrom
fix/wns-contract-input-validation

Conversation

@heifner

@heifner heifner commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

The remaining input-validation findings from CertiK "Wire Network - Sysio Audit 1", closing WIRE-318, WIRE-323, WIRE-324, WIRE-325 and WIRE-326.

No ABI changes — every fix is an added guard, so no SysioContractTypes.ts regeneration and no downstream wire-libraries-ts / wire-tools-ts rebuild is required. Only the five .wasm artifacts changed; no .abi did.

Finding Severity Ticket
WNS-03 — roa::reducepolicy accepts negative weights and expands quotas Major WIRE-318
WNS-09 — token::create stores the issuer without validating it Minor WIRE-323
WNS-10 — tokens::regtoken does not bound symbol_name/description Minor WIRE-324
WNS-11 — regchain does not enforce the canonical WIRE code Informational WIRE-325
WNS-12 — unused sysiosystem::system_contract forward declaration Optimization WIRE-326

WNS-03 — negative reduction weights inflate quota

reducepolicy bounded the request only from above (w <= stored), which any negative amount satisfies whenever the stored weight is positive. The weight is then applied as a subtraction, so a negative one increased the account's quota:

new_net = (net_limit < 0) ? -1 : std::max(0, net_limit - net_weight.amount);

A node owner could inflate an account's NET/CPU past the issuer's ROA budget — bypassing expandpolicy's free-allocation check — and desynchronise the reslimit row and the issuer's nodeowners accounting from the policy weights. addpolicy and expandpolicy both reject negatives already; reducepolicy was the outlier.

CertiK's PoC (a 10.0000 SYS policy reduced by −5.0000 SYS ending at 15.0000 SYS) is now reducepolicy_negative_weight_rejected, covering all three weights and asserting the policy and the account's on-chain resource limits are untouched after the rejected attempts.

WNS-09 — unvalidated issuer

issuer was trusted input after require_auth(get_self()), but issue gates on to == st.issuer + require_auth(st.issuer). A null or non-existent issuer produced a token nobody could ever issue while permanently burning the symbol, since create rejects duplicates. Now checks issuer.value != 0 and is_account(issuer), placed after the supply checks so existing error ordering is preserved.

WNS-10 — unbounded metadata in system-paid state

CertiK raised this on tokens::regtoken, but the identical unbounded name/description pair, with the same ram_payer = sysio billing, was also on chains::regchain and reserv::regreserve. Rather than fix one instance of a three-instance defect, the limits live once in a new shared header:

contracts/sysio.opp.common/include/sysio.opp.common/registry_metadata.hpplabel_max_bytes = 128, description_max_bytes = 256 (the latter matching the established token::issue memo bound).

All three privileged registrations call check_metadata() before emplace.

The label bound started at 32 and was wrong

The e2e gate caught it: run 31258456527 came back 6/13, two failures mine. flow-reserve-lifecycle and flow-swap-private-reserves create reserves through create_reserveoncrtreserve, and four of their labels exceed 32 bytes (longest 42). Over-bound metadata routes into the CANCELLED/refund path, so a reserve those flows expect PENDING came back CANCELLED.

32 was wrong, not the names. WNS-10 is about unbounded strings consuming up to the KV/action ceiling of system-paid state; it says nothing about how terse a label should be, and a reserve naming its full leg is what a reader wants in the registry. The bound is 128 — still a real cap, with room for descriptive names rather than sized to the current longest. The header says so explicitly, so the next person who hits it questions the bound before shortening a legitimate label.

reserv::oncrtreserve needed different handling

It carries the same two strings from an outpost-side creator, but it is an OPP inbound dispatch handler — a check() there rolls back the consensus-tipping delivery and stalls epoch advancement chain-wide (feedback_opp_handlers_never_throw, epoch-stall-is-fatal). It instead uses the non-throwing metadata_exceeds_bounds() and joins the existing reject predicate alongside invalid_amount and the unlinked-creator case, releasing the creator's escrow through the RESERVE_CREATE_CANCELLED flow that path already emits.

The CANCELLED tombstone is itself a sysio-billed row storing name/description, so writing the oversized strings onto it would persist exactly the state the bound prevents. The rejected row stores a fixed <rejected> marker as its name and an empty description — nothing is truncated. Truncation would have had to cut at a byte offset rather than a UTF-8 code-point boundary, and the salvaged text buys nothing: nothing reads a tombstone's metadata (the reclaim path overwrites every field) and the creator's originals are preserved in the inbound OPP envelope artifact regardless. The earlier truncate_label / truncate_description helpers were removed.

oncrtreserve_oversized_metadata_is_cancelled covers three cases — an over-bound ASCII label (129 bytes), an over-bound multibyte label (127 ASCII + é = 129 bytes, which a byte-wise clamp would have split), and an over-bound description (257 bytes) behind an in-bound name so each half of metadata_exceeds_bounds is driven independently. All three assert CANCELLED, the <rejected> marker, and — for the description case — an empty stored description.

The header makes the split explicit: privileged abort-safe registrations check; dispatch handlers ask and route.

WNS-11 — depot identity not pinned to its code

Bootstrap invariant V3 (docs/platform-bootstrap-config.md) is "exactly one CHAIN_KIND_WIRE chain, code WIRE". Only the cardinality half was enforced on-chain, and is_depot is derived from the kind alone — so a registration could claim depot identity under any code (FAKE), with the code's validity resting entirely on the off-chain config validator.

The guard is bidirectional: kind == CHAIN_KIND_WIRE requires code == "WIRE", and the else branch rejects code == "WIRE" under any other kind. The forward check alone still admitted regchain(EVM, "WIRE", ...), which — with code uniqueness and no erase action — would have permanently bricked registration of the canonical depot row. Both orderings are covered in sysio.epoch_tests.cpp.

WNS-12

Removed the unused sysiosystem::system_contract forward declaration from sysio.token.hpp; it implied a sysio.tokensysio.system dependency that does not exist.

Verification

Re-run at the final head (f8ba67a):

Binary Result
contracts_unit_test (full, --sys-vm) 589 cases, no errors
unit_test (full, --sys-vm) 1515 cases, no errors
plugin_test no errors

unit_test matters here beyond the usual sweep: unittests/test_contracts.hpp.in loads sysio.token.wasm from the same build path, so the new is_account(issuer) check is live in those tests too. Every token::create call site in unittests/ uses alice or sysio.token, both created in their fixtures.

Harness compatibility was re-verified against the actual data rather than a sample: every *Name / *Description literal across all wire-tools-ts packages fits, with the longest label at 42 bytes (3× headroom under 128) and the longest description at 137. The original check looked only at RegistrySteps.ts — the bootstrap registrations, max 23 bytes — and missed the flow-level create_reserve names, which is exactly where the 32-byte bound broke.

Reviewer note

The WNS-10 fix extends past CertiK's literal regtoken scope to the three sibling registries plus the oncrtreserve dispatch path. That was a deliberate call — same defect, same billing account — but it is the one part of this PR that is wider than the finding, so it is the part worth a second opinion.

…3, WNS-09, WNS-10, WNS-11, WNS-12)

The remaining input-validation findings from CertiK "Wire Network - Sysio
Audit 1". No ABI changes — every fix is an added guard.

[Major] roa::reducepolicy accepted negative NET/CPU/RAM weights.
The action bounded the request only from above (`w <= stored`), a condition any
negative amount satisfies whenever the stored weight is positive. The weight is
then applied as a SUBTRACTION, so a negative one INCREASED the account's quota:
`new_net = max(0, net_limit - net_weight.amount)`. A node owner could inflate an
account's NET/CPU past the issuer's ROA budget — bypassing expandpolicy's
free-allocation check — and desynchronise the reslimit row and the issuer's
nodeowners accounting from the policy weights. addpolicy and expandpolicy both
reject negatives already; reducepolicy was the outlier. CertiK's PoC (a 10.0000
SYS policy reduced by -5.0000 SYS ending at 15.0000 SYS) is now a regression
test asserting the policy and the account's resource limits are untouched.

[Minor] token::create stored an unvalidated issuer.
`issuer` was trusted input after `require_auth(get_self())`, but `issue` gates on
`to == st.issuer` + `require_auth(st.issuer)`. A null or non-existent issuer
therefore produced a token nobody could ever issue while permanently burning the
symbol, since `create` rejects duplicates. Now checks `issuer.value != 0` and
`is_account(issuer)`, after the supply checks so existing error ordering holds.

[Minor] Registry metadata was unbounded in system-paid state.
`tokens::regtoken` moved `symbol_name` and `description` into a persisted row
billed to `ram_payer = sysio` — the shared system pool — without bounding either,
letting each unique code consume up to the KV/action ceiling. The identical
unbounded pair, with the same billing, was on `chains::regchain` and
`reserv::regreserve`, so the limits live once in a new shared header
(`sysio.opp.common/registry_metadata.hpp`: 32-byte label, 256-byte description —
the latter matching the established `token::issue` memo bound) and all three
enforce them via `check_metadata` before emplace.

`reserv::oncrtreserve` carries the same two strings from an outpost-side creator
but is an OPP inbound dispatch handler, which must never abort: a check() there
rolls back the consensus-tipping delivery and stalls epoch advancement
chain-wide. It uses the non-throwing `metadata_exceeds_bounds` and joins the
existing reject predicate, releasing the creator's escrow through the
RESERVE_CREATE_CANCELLED flow already used for an invalid amount or an unlinked
creator. The CANCELLED tombstone is itself a sysio-billed row, so its metadata is
clamped via truncate_label/truncate_description — storing it verbatim would
persist exactly the state the bound prevents.

[Informational] regchain did not enforce the depot's canonical code.
Bootstrap invariant V3 (docs/platform-bootstrap-config.md) is "exactly one
CHAIN_KIND_WIRE chain, code WIRE". Only the cardinality half was on-chain, and
`is_depot` is derived from the kind alone, so a registration could claim depot
identity under any code and the code's validity rested entirely on the off-chain
config validator.

[Optimization] Removed the unused sysiosystem::system_contract forward
declaration from sysio.token.hpp — it implied a sysio.token -> sysio.system
dependency that does not exist.

Verified: contracts_unit_test 588 cases, unit_test 1515 cases, plugin_test —
all green, no errors.

Change-Id: Ia903c97aace16597352dd18e9a892568f17e5433
@heifner
heifner requested review from a team and huangminghuang August 7, 2026 18:36

@huangminghuang huangminghuang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two actionable findings from the delegated OCR review.

// under any code (e.g. `FAKE`) -- previously only the cardinality half was on-chain
// and the code depended entirely on the off-chain config validator.
if (kind == opp::types::CHAIN_KIND_WIRE) {
sysio::check(code == WIRE_CHAIN_CODE,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Reserve the WIRE code bidirectionally

This guard only handles kind == CHAIN_KIND_WIRE. During bootstrap, regchain(CHAIN_KIND_EVM, "WIRE", ...) can still succeed first; code uniqueness then permanently prevents registering the canonical depot row, and there is no erase action. Enforce the inverse implication too: code == WIRE_CHAIN_CODE must require kind == CHAIN_KIND_WIRE, with a regression test for this ordering.

Comment on lines +85 to +92
inline std::string truncate_label(std::string label) {
if (label.size() > label_max_bytes) label.resize(label_max_bytes);
return label;
}

/// Clamp a description for storage on a reject-path tombstone row. See `truncate_label`.
inline std::string truncate_description(std::string description) {
if (description.size() > description_max_bytes) description.resize(description_max_bytes);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] Preserve UTF-8 when truncating metadata

resize() truncates bytes rather than UTF-8 code points. A valid 33-byte label consisting of 31 ASCII bytes plus é is cut midway through the final character, persisting malformed human-readable metadata in the CANCELLED row. Clamp at a valid UTF-8 boundary or store a safe fixed/empty tombstone value, and add a multibyte boundary test.

…F-8 boundaries

Addresses review on #551 (both findings).

[P2] Reserve the `WIRE` code from every non-WIRE kind.
The guard only enforced the forward implication (kind WIRE => code WIRE), so
`regchain(CHAIN_KIND_EVM, "WIRE", ...)` still succeeded. Chain codes are unique
and there is NO erase action, so such a row would permanently squat the depot's
identity and leave the canonical self-row unregisterable — bricking bootstrap
with no on-chain recovery. The inverse is now enforced too, with a distinct
message so the two failures stay diagnosable. The regression test attempts the
squat BEFORE the depot row exists, since the ordering is the whole point, then
asserts the canonical row still registers.

[P3] Clamp tombstone metadata on a UTF-8 code-point boundary.
`resize()` cuts at a byte offset, not a character boundary: a 33-byte label of
31 ASCII bytes plus `é` (0xC3 0xA9) clamped to 32 kept a lone 0xC3 lead byte and
persisted malformed text in state. `clamp_utf8` walks back off continuation
bytes so a straddling character is dropped whole. The bound itself stays a BYTE
bound — it exists to cap state size. Test asserts the stored label is the 31
ASCII bytes, not 32.

Verified: contracts_unit_test 590 cases, no errors.
Change-Id: Ibfd23d34b8b9220113761ad545017f8a2c011895
@heifner

heifner commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Both findings were real and are fixed in 537d436. Thanks — P2 in particular was a genuine gap, not just a hardening nit.

[P2] Reserve the WIRE code bidirectionally — confirmed and fixed.

You're right that the forward implication alone left the hole open, and the consequence is worse than a mis-registration: chain codes are unique and there is no erase action, so an EVM/SVM row registered under WIRE permanently squats the depot's identity and leaves the canonical self-row unregisterable — bootstrap bricked with no on-chain recovery path. The inverse is now enforced on the else branch with its own message, so "wrong code for a WIRE chain" and "reserved code on a non-WIRE chain" stay separately diagnosable.

regchain_wire_code_reserved_from_other_kinds attempts the squat with both EVM and SVM before the depot row exists — the ordering being the whole point of the finding — then asserts the canonical row still registers afterwards and that non-WIRE codes are unaffected.

[P3] Preserve UTF-8 when truncating — confirmed and fixed.

Your example is exactly what it did: 31 ASCII + é (0xC3 0xA9) at a 32-byte clamp kept the lone 0xC3 lead byte. I went with clamping to a valid boundary rather than a fixed/empty tombstone value, since the tombstone's metadata has no consumer in logic — the reclaim branch overwrites every field — so its only remaining value is forensic ("what did the creator try to name it"), and an empty value throws that away.

clamp_utf8 walks back off continuation bytes (10xxxxxx) so a straddling character is dropped whole; it's in the shared header, so both truncate_label and truncate_description get it. The bound itself deliberately stays a byte bound — it exists to cap state size, and the input is operator-relayed bytes with no guarantee of being well-formed UTF-8 in the first place. oncrtreserve_metadata_clamp_respects_utf8 asserts the stored label is the 31 ASCII bytes, not 32.

Verification: contracts_unit_test 590 cases, no errors (588 before these two tests). unit_test and plugin_test are unchanged from the earlier green run — this round touched only sysio.chains, sysio.reserv and the shared header, none of which those binaries load.

@heifner
heifner requested a review from huangminghuang August 7, 2026 19:17

@huangminghuang huangminghuang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One low-severity project-rule finding from the follow-up review.

// character straddling the boundary is being split, so walk back onto its lead byte and
// drop the whole sequence. Terminates at 0 in the worst case.
std::size_t cut = max_bytes;
while (cut > 0 && (static_cast<unsigned char>(s[cut]) & 0xC0) == 0x80) --cut;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] Name the UTF-8 bit masks — The repository’s “No magic literals” rule requires nontrivial numeric values behind named constants. These masks define the correctness of the UTF-8 boundary check, so introduce named inline constexpr values for 0xC0 and 0x80 and use them here.

Addresses review on #551 (the UTF-8 bit-mask comment, by removing the code it
refers to).

The tombstone no longer carries the creator's over-bound strings at all: `name`
becomes the fixed marker `<rejected>` and `description` is cleared. That deletes
`clamp_utf8`, `truncate_label`, `truncate_description` and the boundary test
along with the masks.

Truncation was the wrong tool for this slot. It has to cut at a byte offset
rather than a UTF-8 code-point boundary, so it needs the boundary walk purely to
avoid corrupting its own input — and the text it salvages buys nothing: nothing
reads a tombstone's metadata (the reclaim path overwrites every field) and the
creator's originals are preserved in the inbound OPP envelope artifact
regardless. A fixed marker also states plainly that the row was rejected rather
than leaving a blank a reader has to interpret.

Worth recording for anyone revisiting this: the bound remains a state-size
control, NOT a UTF-8 guarantee. `check_metadata` measures bytes only, so
malformed input under the bound still reaches state verbatim — and that is
harmless, because `fc::json::escape_string` already calls `prune_invalid_utf8`
before emitting, so no malformed byte reaches a client through get_table_rows.

The substitution is conditional on `oversized_metadata`. The other two reject
reasons on that shared predicate — an unlinked creator and an invalid amount —
carry perfectly valid, in-bounds metadata, so they keep it exactly as before.

The regression test now covers both an ASCII and a multibyte over-bound label:
with nothing truncated there is no code-point boundary left to split.

Verified: contracts_unit_test 589 cases, no errors.
Change-Id: I037004f1f8b8d6a00ea436d69b32dde6fb0b945d
@heifner

heifner commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Resolved in 06c9473, but by deleting the code rather than naming the masks — the mask comment is correct, and prompted a look at whether the boundary walk was earning its place. It wasn't.

The tombstone no longer carries the over-bound strings at all. name becomes a fixed <rejected> marker, description is cleared. That removes clamp_utf8, both truncate helpers, the boundary test and the masks with them — this is the "safe fixed tombstone value" option from your original P3.

Two things that made truncation the wrong tool for this slot:

  1. The salvaged text buys nothing. Nothing reads a tombstone's metadata — the reclaim path overwrites every field — and the creator's originals are preserved in the inbound OPP envelope artifact regardless. So the boundary walk existed purely to stop the truncation corrupting its own input, protecting a value with no consumer.
  2. The bound was never a UTF-8 guarantee, and shouldn't be read as one. check_metadata measures bytes only, so malformed input under the bound already reaches state verbatim — including the live PENDING row, not just the tombstone. The clamp only prevented self-inflicted malformation. It's also harmless: fc::json::escape_string calls prune_invalid_utf8 before emitting (libfc/src/io/json.cpp:594), so no malformed byte reaches a client through get_table_rows.

One correctness point worth flagging, since it isn't visible from the diff alone: the substitution is conditional on oversized_metadata. That reject block is shared with the unlinked-creator and invalid-amount cases, whose metadata is valid and in-bounds — blanket-marking those would have been a silent behaviour change and would have made the marker meaningless. They keep their metadata exactly as before.

The regression test now covers an ASCII and a multibyte over-bound label, which is your multibyte case answered from the other direction: with nothing truncated there is no code-point boundary left to split.

Verification: contracts_unit_test 589 cases, no errors.

Separately, and unrelated to this change — on one full-suite run I saw sysio_msig_tests/propose_invalidate_approve fail with an unexpected exception. It did not reproduce: the next full run was green, and the suite passed 12/12 standalone. I can't attribute it to this PR (msig contract and tests are untouched here, and each fixture builds a fresh chain), but flagging it since that test exercises the invalidateapproveexec path that WNS-07 / WIRE-321 reports as broken, and that ticket is still open. Happy to baseline it against master if you want it pinned down before merge.

@heifner

heifner commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Correction to my note above: the intermittent failure is not related to WNS-07 / WIRE-321. That guess was wrong, and the real cause is unrelated to this PR.

Running it down properly — master baselined at 580 cases, this branch at 589 — the failures turned out to be a wall-clock budget in the test harness, not a logic defect:

abi_serialization_deadline_exception: ABI serialization time has exceeded the deadline
serialization time limit 1000000us exceeded
    contracts_unit_test  abi_serializer.cpp:450 validate
    contracts_unit_test  tester.cpp:936 get_action
// libraries/testing/tester.cpp:123
const fc::microseconds base_tester::abi_serializer_max_time{1000*1000}; // 1s for slow test machines

That accounts for every observation. The failing test wanders across unrelated suites run to run — sysio_msig_tests, t5_emissions_tests, sysio_councl_tests, sysio_msgch_chain_tests, plus core dumps from sysio_dispatch_tests in earlier sessions — which is the signature of a shared time budget, not of a defect (a defect fails the same test). It never reproduces standalone: the msig suite passed 12/12 on its own. Filed as WIRE-328.

Measured tally: 3 failures across 13 full runs on this branch, 0 across 6 on master. I'd caution against reading the master number as a clean bill of health — at the observed ~23% rate, P(0 failures in 6) ≈ 0.21, so master's baseline is compatible with the same flakiness rather than distinguishable from it. The exception text is what settles this, not the counts.

The one part genuinely attributable here: this PR adds nine test cases, so each full run does marginally more work and has marginally more exposure to the deadline. That is a magnitude nudge on a pre-existing harness limit, not something introduced by the change.

@heifner
heifner requested a review from huangminghuang August 7, 2026 21:50

@huangminghuang huangminghuang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One low-severity test-coverage finding from the latest follow-up review.

Comment thread contracts/tests/sysio.reserv_tests.cpp Outdated
("token_code", codename_mvo("ETH"))
("reserve_code", codename_mvo(reserve_code))
("name", name)
("description", "")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] Cover the oversized-description path — This regression test always passes an empty description, so it never exercises the description.size() > description_max_bytes half of metadata_exceeds_bounds or verifies that an over-bound description cannot be persisted. Add a description-only case with an in-bound name and a 257-byte description, then assert CANCELLED status, the <rejected> marker, and an empty stored description.

…ames

The e2e gate caught this: run 31258456527 came back 6/13, and two of the seven
failures are mine. `flow-reserve-lifecycle` and `flow-swap-private-reserves`
create reserves through `create_reserve` -> `oncrtreserve`, and four of their
labels are longer than the 32 bytes I picked:

  ETHEREUM-ETH/WIRE unlinked-creator reserve   42
  SOLANA-USDCSOL/WIRE private reserve          35
  ETHEREUM-ETH/WIRE private reserve            33  (x2)

Over-bound metadata routes into the CANCELLED/refund path, so the reserve those
flows expect PENDING came back CANCELLED.

32 was wrong, not the names. WNS-10 is about UNBOUNDED strings consuming up to
the KV/action ceiling of system-paid state; it says nothing about how terse a
label should be, and a reserve naming its full leg is exactly what a reader
wants to see in the registry. The bound is now 128 — still a real cap, with
room for descriptive names rather than sized to the current longest. The
header says so explicitly, so the next person hitting it questions the bound
before shortening a legitimate label.

The 256-byte description bound is unchanged and unaffected.

Verified against the actual data this time rather than a sample: every
`*Name`/`*Description` literal across all wire-tools-ts packages now fits, with
the longest label at 42 bytes (3x headroom) and the longest description at 137.
My earlier check looked only at `RegistrySteps.ts` — the bootstrap
registrations, max 23 bytes — and concluded the harness fit. It did not cover
the flow-level `create_reserve` names, which is precisely where this broke.

Tests rebased off the old boundary: the over-bound cases (33 -> 129 bytes), the
inclusive at-limit assertions (32 -> 128), the error-text expectations, and the
multibyte UTF-8 case (31 ASCII + `é` -> 127 ASCII + `é`).

contracts_unit_test: 589 cases, no errors.

Change-Id: I86784a8f866e94d540f63f8a59ee9030d18b75db
@heifner

heifner commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

The e2e gate found a real bug in this PR, and it was mine. Fixed in 119dcc7.

Run 31258456527 came back 6/13. Two of the seven failures are attributable to this branch: flow-reserve-lifecycle and flow-swap-private-reserves create reserves through create_reserveoncrtreserve, and four of their labels exceed the 32-byte bound I picked:

Label Bytes
ETHEREUM-ETH/WIRE unlinked-creator reserve 42
SOLANA-USDCSOL/WIRE private reserve 35
ETHEREUM-ETH/WIRE private reserve (×2) 33

Over-bound metadata routes into the CANCELLED/refund path, so the reserve those flows expect PENDING came back CANCELLED.

32 was wrong, not the names. WNS-10 is about unbounded strings consuming up to the KV/action ceiling of system-paid state — it says nothing about how terse a label should be, and a reserve naming its full leg is exactly what a reader wants in the registry. The bound is now 128: still a real cap, sized with room for descriptive names rather than to the current longest. The header comment says so explicitly, so the next person who hits it questions the bound instead of shortening a legitimate label.

The 256-byte description bound is unchanged and was never close to tripping.

On the verification I claimed earlier. I previously wrote that harness metadata "fits comfortably" — I had checked RegistrySteps.ts, the bootstrap chain/token registrations, max 23 bytes, and generalised from it. That never covered the flow-level create_reserve names, which is exactly where this broke. This time I swept every *Name/*Description literal across all wire-tools-ts packages: longest label 42 bytes (3× headroom under 128), longest description 137 (1.9× under 256), zero over-bound.

Worth noting for later: the description bound now has materially less headroom than the label bound. 256 came from the token::issue memo precedent rather than from this data, and at 137 bytes flow-underwriter-slashing is already at 54% of it.

Tests rebased off the old boundary — over-bound cases 33 → 129 bytes, inclusive at-limit assertions 32 → 128, the error-text expectations, and the multibyte UTF-8 case (31 ASCII + é → 127 ASCII + é).

contracts_unit_test: 589 cases, no errors.

The other five failures

swap-from-wire, swap-to-wire, swap-with-underwriting, swap-non-native-tokens and swap-variance-revert are not explained by this branch — that run carried wire-tools-ts@master, which does not yet have the client-side quote fix in wire-tools-ts#59 for #550's fee change. A gate run for #59 is in flight; I'll re-run this branch once it lands so the two causes are not conflated.

@heifner

heifner commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

E2E gate green — 31390895605, 13/13

wire-sysio     → fix/wns-contract-input-validation   (this PR)
wire-tools-ts  → fix/swap-quote-mirror-wire-leg-fee  (wire-tools-ts#59)
everything else → manifest defaults (master, incl. #550)

The two flows the 32-byte label bound broke now pass: reserve-lifecycle ✅ 928s and swap-private-reserves ✅ 1798s. Every other flow passes too, so the registry guards — regchain's bidirectional WIRE reservation, the metadata bounds on regtoken/regchain/regreserve, the oncrtreserve soft-validate, token::create's issuer check — clear a real bootstrap and a full flow suite.

That closes the gap reported earlier: run 31258456527 was 6/13, of which two failures were this branch's (over-bound reserve labels) and five were #550's fee change awaiting the wire-tools-ts fix.

This run does not gate this PR alone. It carries wire-tools-ts#59, and it had to: swap-private-reserves is both a flow this branch broke and one of the four #59 repairs, so with tools-ts@master it would fail for the fee reason regardless and the result would be unreadable. The two branches are verified green together. If you want this branch gated independently, that needs a run once #59 merges.

contracts_unit_test remains 589 cases, no errors.

`oncrtreserve_oversized_metadata_is_cancelled` drove only over-bound labels; every
case passed an empty description, so `metadata_exceeds_bounds`' second disjunct was
never evaluated and nothing asserted that an over-bound description stays out of
state.

Adds a third case: an in-bound name with a 257-byte description (one over
`description_max_bytes`), so the description is the sole rejection reason. Asserts
the row lands CANCELLED, the name reads the `<rejected>` marker, and the stored
description is empty.

The per-case helper now takes the description explicitly rather than pinning it to
"", which is what limited the existing cases to the label half.

Change-Id: Id84800a46cadcada31e36986794ddf1e41078dc3
@heifner

heifner commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

All four threads addressed. f8ba67a covers the one that was still open; the other three were resolved by earlier commits on the branch, so here is where each landed.

[P3] Cover the oversized-description path — fixed in f8ba67a. You were right that the case never ran: the per-case helper pinned description to "", so metadata_exceeds_bounds' second disjunct was never evaluated. The helper now takes the description explicitly, and a third case drives an in-bound name with a 257-byte description (one over description_max_bytes), asserting CANCELLED, the <rejected> marker, and an empty stored description.

[P2] Reserve the WIRE code bidirectionally — done in 537d436 (sysio.chains.cpp:79-82). The else branch enforces the inverse implication, so regchain(CHAIN_KIND_EVM, "WIRE", ...) is rejected with "the code WIRE is reserved for the depot self-row" and can no longer take the code before the canonical row registers. Ordering regression tests are in sysio.epoch_tests.cpp — both directions, at lines 281 (kind=WIRE with a non-WIRE code) and 304/306 (code=WIRE with a non-WIRE kind).

[P3] Preserve UTF-8 when truncating metadata — obsoleted by 06c9473, which removed truncation entirely. Over-bound metadata is no longer clamped onto the row; the tombstone stores the fixed <rejected> marker with an empty description. Since nothing is truncated there is no code-point boundary to split. The multibyte case you asked for is still covered as a regression (127 ASCII + é = 129 bytes) — it now asserts the marker rather than a boundary-clamped prefix.

[P3] Name the UTF-8 bit masks — moot under the same commit: the 0xC0 / 0x80 masks are gone along with the boundary check, so there are no literals left to name.

Verification: contracts_unit_test -- --sys-vm, 589 cases, *** No errors detected.

@heifner
heifner requested a review from huangminghuang August 10, 2026 20:33

@huangminghuang huangminghuang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved — the code review is clean.

Before merge, please refresh the PR description so it matches the final implementation: label_max_bytes is 128 (not 32); oversized tombstones now store <rejected> and clear the description (the truncation helpers were removed); the compatibility note should reflect the 42-byte real labels/e2e follow-up; and the verification counts should be updated for the final head.

@heifner
heifner merged commit 22c13e5 into master Aug 10, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants